feat(rdpsnd)!: misuse-resistant format negotiation for RdpsndServerHandler#1359
feat(rdpsnd)!: misuse-resistant format negotiation for RdpsndServerHandler#1359clintcan wants to merge 2 commits into
Conversation
f6e94a5 to
72d28d2
Compare
|
Friendly ping on this when you have a moment 🙂 — it's been a few weeks and I wanted to make sure it didn't slip off the radar. This implements the exact two-method shape we settled on in #1356: Two small in-PR points I'd flagged for your call are still open whenever you review: (1) the |
72d28d2 to
d657272
Compare
|
Thank you! I added this PR to my review queue! I’m requesting a Copilot review in the meantime. |
There was a problem hiding this comment.
Pull request overview
This PR updates the ironrdp-rdpsnd server-side handler API to make audio format negotiation misuse-resistant by moving the server∩client intersection and wFormatNo (client-list index) computation into the crate, and splitting “selection” from “lifecycle start”. The bundled ironrdp example server is updated accordingly, and unit tests are added/enabled to validate the negotiation logic.
Changes:
- Introduces
NegotiatedFormatand replacesRdpsndServerHandler::start(&ClientAudioFormatPdu) -> Option<u16>withchoose_format(&[NegotiatedFormat]) -> Option<&NegotiatedFormat>plusstart(&NegotiatedFormat). - Adds crate-owned negotiation helpers (
negotiate_formats,audio_format_eq) and unit tests covering index mapping and overlap behavior. - Updates
crates/ironrdp/examples/server.rsto use the new two-step negotiation API.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| crates/ironrdp/examples/server.rs | Updates the example RDPSND handler implementation to the new choose_format + start API. |
| crates/ironrdp-rdpsnd/src/server.rs | Adds NegotiatedFormat, negotiation logic, handler API change, and unit tests. |
| crates/ironrdp-rdpsnd/Cargo.toml | Enables the lib test harness so the new unit tests run. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // `chosen` borrows `common` (not `self`), so the encoder | ||
| // is read off it and the handler is free to borrow again | ||
| // for the `start` lifecycle hook. | ||
| let wformat_no = chosen.wformat_no; | ||
| self.handler.start(chosen); |
| warn!("Invalid OPUS channels: {}", n); | ||
| return Some(0); | ||
| return; |
There was a problem hiding this comment.
issue: start() now returns (). So format_no is committed before start runs, and start has no way to say "I couldn't initialize." We can see in this example where the encoder creation fails and we do a bare return, after which the library still sets format_no = Some(wformat_no) and considers the stream live. The problem is that at this point no producer task was spawned, and we have a silent "negotiated, no audio" state, which is a failure class I wish we could kill in this PR. I think we need to either return Result<(), E> or a bool so the library can roll back to None.
| [lib] | ||
| doctest = false | ||
| test = false | ||
| # test = false |
There was a problem hiding this comment.
issue: test = false is deliberate policy here, not incidental, let's not re-enable it.
Keeping the lib free of an inline test harness is a constraint we hold across the workspace, and a commented-out manifest key will just rot. The behavior is well worth testing though. Here is a route that keeps the policy intact:
- add an
__internalfeature (doc-hidden, excluded fromdefault), - behind it, expose thin
#[doc(hidden)] pubshims over the private items:negotiate_formats,audio_format_eq, and awformat_no()accessor onNegotiatedFormat(the current tests read the privatewformat_nofield directly, which only works because they live in-module, so the shim has to surface that too), - move the tests into the appropriate testsuite crate
[lib] test = false stays, the internal surface stays out of the public API and docs, and the assertions you already wrote port over almost verbatim.
| /// Compare two audio formats by their WAVEFORMATEX identity — wave format tag, | ||
| /// channel count, sample rate, and bit depth. Derived fields | ||
| /// (`n_avg_bytes_per_sec`, `n_block_align`) and the codec-specific `data` blob | ||
| /// are deliberately ignored: a client echoes back a format it accepts but is | ||
| /// not guaranteed to reproduce those byte-for-byte. | ||
| fn audio_format_eq(a: &pdu::AudioFormat, b: &pdu::AudioFormat) -> bool { | ||
| a.format == b.format | ||
| && a.n_channels == b.n_channels | ||
| && a.n_samples_per_sec == b.n_samples_per_sec | ||
| && a.bits_per_sample == b.bits_per_sample | ||
| } |
There was a problem hiding this comment.
issue: ignoring the codec data blob is unsafe for some formats we actually advertise.
Agreed on the derived fields (n_avg_bytes_per_sec / n_block_align are computable and a client may legitimately not echo them), but the data blob is a different category: for codecs whose extra-format bytes carry real configuration (AAC is the clear case per MS-RDPEA 2.2.2.1.1's cbSize/extra data, worth confirming per codec in our get_formats), blanket-ignoring it can match two genuinely incompatible variants.
Concretely, with the current looseness plus format: server_format.clone() in negotiate_formats: we stream the server's definition while wFormatNo points the client at its entry. If they differ only in data, the server encodes one config and the client decodes another, garbled/no audio. Two distinct server formats differing only in data also collapse onto the same client index, yielding two NegotiatedFormats sharing one wFormatNo.
PCM is fine (no extra data); the codecs that aren't need either codec-aware matching or data included in the comparison for those. Note equality_uses_waveformatex_identity_only currently encodes the too-loose decision, so it'd flip with this.
| // Formats common to server and client, in the server's | ||
| // preference order, each tagged with its wFormatNo (its | ||
| // position in the *client's* list). Keeping this in the crate | ||
| // means the handler never does index arithmetic and can't emit | ||
| // an out-of-range wFormatNo. | ||
| let common = negotiate_formats(self.handler.get_formats(), &client_format.formats); | ||
| self.state = RdpsndState::Ready; | ||
| self.format_no = self.handler.start(client_format); | ||
| self.format_no = if common.is_empty() { | ||
| debug!("No audio format in common with the client; audio disabled"); | ||
| None | ||
| } else if let Some(chosen) = self.handler.choose_format(&common) { | ||
| // `chosen` borrows `common` (not `self`), so the encoder | ||
| // is read off it and the handler is free to borrow again | ||
| // for the `start` lifecycle hook. | ||
| let wformat_no = chosen.wformat_no; | ||
| self.handler.start(chosen); | ||
| Some(wformat_no) | ||
| } else { | ||
| debug!("Handler declined every common audio format; audio disabled"); | ||
| None | ||
| }; |
There was a problem hiding this comment.
thought: I would value a test on the SvcProcessor wiring over the isolated helper tests; but fine as a follow-up, or I may pick it up myself.
The helpers are well covered; the part that's new and easy to get wrong is the state-machine path in process: choose_format skipped when common is empty, decline -> None, the captured wformat_no matching the chosen format, start called exactly once. A black-box test: drive the processor with a Client Audio Formats PDU through a fake handler, emit a wave, assert the wFormatNo on the outgoing PDU; it would exercises all of that through the public surface, so it would live as a normal tests/ integration test and wouldn't even need the __internal shim from the Cargo.toml thread.
Not blocking: happy to take it as a follow-up PR, or leave it 🙂
…are codec data Review follow-up for Devolutions#1359. - `start` now returns `Result<(), Box<dyn RdpsndError>>`. On `Err` the crate declines the negotiated format (clears `format_no`) — the same outcome as `choose_format` returning `None` — instead of leaving the channel "negotiated" but silently producing no audio. `format_no` is committed *before* `start` so a producer that emits a wave during `start` can't race an unset index, and is rolled back to `None` on failure. The example server's two encoder-init failures now return `Err`. - Restore `[lib] test = false` (workspace policy) and move the negotiation tests out of the lib. Add a private `__test` feature (`dep:visibility`) exposing `negotiate_formats` / `audio_format_eq` / `NegotiatedFormat::wformat_no` via `visibility::make(pub)`; the tests live in `ironrdp-testsuite-core` (`tests/rdpsnd/server.rs`), which depends on `ironrdp-rdpsnd` with `__test` — mirroring `ironrdp-cliprdr`. - `audio_format_eq` now compares the codec extra-data blob (`data`). The two derived fields (`n_avg_bytes_per_sec`, `n_block_align`) stay ignored, but the `data` blob carries real configuration for some codecs (AAC's HEAACWAVEINFO, MS-RDPEA 2.2.2.1.1), so ignoring it could match two genuinely incompatible formats. cbSize=0 still decodes to `None` on both sides, so PCM/AAC negotiation is unaffected. - Add black-box `SvcProcessor` wiring tests (via the public surface): a shared format streams (start called once, format committed), no common format skips `choose_format`, and a failing `start` declines (no audio streamed).
|
Thanks for the thorough review Benoît Cortier (@CBenoit)! Pushed a follow-up addressing all of it:
Green locally: |
…ndler
The old `start(&ClientAudioFormatPdu) -> Option<u16>` made every handler
compute `wFormatNo` itself, as an index into the *client's* format list.
Getting it wrong (e.g. returning a server-list index) yields
`wFormatNo >= NumberOfClientFormats`, which a compliant client rejects,
silently dropping all audio — and each handler re-implemented the same
server-vs-client intersection.
Move that work into the crate and split selection from lifecycle:
fn choose_format<'a>(&mut self, common: &'a [NegotiatedFormat]) -> Option<&'a NegotiatedFormat>;
fn start(&mut self, format: &NegotiatedFormat);
The crate computes `common` (formats from `get_formats()` the client also
advertised, in the server's preference order, each tagged with its
client-list `wFormatNo`), calls `choose_format`, then `start` with the
chosen format. `NegotiatedFormat` has a private `wformat_no` and no public
constructor, and `choose_format` returns a borrow of an element of `common`,
so a handler cannot pick a format the client did not accept nor emit an
out-of-range `wFormatNo`. `choose_format` is not called when nothing is in
common. Separating `choose_format` (pure selection) from `start` (encoder
init / producer startup) keeps the two concerns distinct.
Resolves the footgun documented in Devolutions#1343. Closes Devolutions#1356.
BREAKING CHANGE: `RdpsndServerHandler::start` is replaced by `choose_format`
(selection) plus `start(&NegotiatedFormat)` (lifecycle). Implementors now
return a `&NegotiatedFormat` from `choose_format` instead of computing a
`wFormatNo`.
- Add `NegotiatedFormat` (private `wformat_no`, `format()` accessor) and
`negotiate_formats` (intersection + client-index mapping), with unit tests
for client-index mapping, the PCM-only/AAC-first regression, empty overlap,
and WAVEFORMATEX-identity equality.
- Match formats on WAVEFORMATEX identity (tag/channels/rate/bits), ignoring
derived and codec-`data` fields a client may not echo verbatim.
- Update the example server to the new two-method shape.
…are codec data Review follow-up for Devolutions#1359. - `start` now returns `Result<(), Box<dyn RdpsndError>>`. On `Err` the crate declines the negotiated format (clears `format_no`) — the same outcome as `choose_format` returning `None` — instead of leaving the channel "negotiated" but silently producing no audio. `format_no` is committed *before* `start` so a producer that emits a wave during `start` can't race an unset index, and is rolled back to `None` on failure. The example server's two encoder-init failures now return `Err`. - Restore `[lib] test = false` (workspace policy) and move the negotiation tests out of the lib. Add a private `__test` feature (`dep:visibility`) exposing `negotiate_formats` / `audio_format_eq` / `NegotiatedFormat::wformat_no` via `visibility::make(pub)`; the tests live in `ironrdp-testsuite-core` (`tests/rdpsnd/server.rs`), which depends on `ironrdp-rdpsnd` with `__test` — mirroring `ironrdp-cliprdr`. - `audio_format_eq` now compares the codec extra-data blob (`data`). The two derived fields (`n_avg_bytes_per_sec`, `n_block_align`) stay ignored, but the `data` blob carries real configuration for some codecs (AAC's HEAACWAVEINFO, MS-RDPEA 2.2.2.1.1), so ignoring it could match two genuinely incompatible formats. cbSize=0 still decodes to `None` on both sides, so PCM/AAC negotiation is unaffected. - Add black-box `SvcProcessor` wiring tests (via the public surface): a shared format streams (start called once, format committed), no common format skips `choose_format`, and a failing `start` declines (no audio streamed).
1a4b6ec to
b0783e4
Compare
Implements the misuse-resistant
RdpsndServerHandlerformat-negotiation API discussed in #1356, in the two-method shape you proposed there.Problem
The old
start(&ClientAudioFormatPdu) -> Option<u16>made every handler computewFormatNoitself — an index into the client's format list. Returning the wrong index (e.g. a server-list index) yieldswFormatNo >= NumberOfClientFormats, which a compliant client rejects, silently dropping all audio. Every handler also re-implemented the same server∩client intersection. (Documented in #1343.)Change
Move the negotiation into the crate and split selection from lifecycle:
common— the formats fromget_formats()the client also advertised, in the server's preference order, each tagged with its client-listwFormatNo— and callschoose_format, thenstartwith the chosen format.NegotiatedFormathas a privatewformat_noand no public constructor, andchoose_formatreturns a borrow of an element ofcommon, so a handler cannot pick a format the client didn't accept nor emit an out-of-rangewFormatNo.choose_formatis not called when nothing is in common.choose_format(pure selection) andstart(encoder init / producer startup) are kept distinct, per your note thatstartis also where producers initialize.Resolves the footgun in #1343. Closes #1356.
Two decisions worth a look (easy to change)
NegotiatedFormat.formatis private with aformat()accessor rather thanpub format—clippy::partial_pub_fieldsrejects mixing apubfield with the privatewformat_no, and this matches the direction of fix(egfx)!: make DecodedFrame fields private with getters to enforce size invariant #1331. Happy to switch topub+#[allow]if you prefer.audio_format_eq: wave-format tag, channels, sample rate, bit depth), deliberately ignoring derived fields (n_avg_bytes_per_sec,n_block_align) and the codecdatablob, which a client may not echo byte-for-byte. This is looser than the type's derived==. I went with identity matching because strict equality can miss formats a client genuinely supports (→ silent no-audio, the very bug this targets), but if you'd rather the crate use full==, it's a one-line change.Notes
RdpsndServerHandler::start. Known downstream implementors that will need a small update:lamco-rdp-server,hypr-rdp,cosmic-ext-rdp-server,ARISU,macrdp. (Per the rdpsnd: make RdpsndServerHandler::start misuse-resistant (hand it the common formats, let the crate compute wFormatNo) #1356 discussion, the straight replacement was preferred over an additive/deprecated path.)Testing
negotiate_formats/audio_format_eq: client-index mapping, the PCM-only/AAC-first regression, empty overlap, and identity equality. (Enabled the lib test harness inCargo.toml.)cargo clippy -p ironrdp-rdpsnd --all-targets -- -D warningsand the example (--features cliprdr,connector,rdpsnd,server) are clean;cargo test -p ironrdp-rdpsndpasses.